Skip to content

Fix GH-16649: Avoid UAF when using array_splice #19399

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: PHP-8.3
Choose a base branch
from

Conversation

alexandre-daubois
Copy link
Member

@alexandre-daubois alexandre-daubois commented Aug 7, 2025

Fix UAF in array_splice when destructors modify the array during splicing. This PR adds a refresh to array pointers to prevent access to stale memory after triggered reallocations.

PHP doesn't crash anymore with this fix, and the address sanitizer doesn't warn anymore.

@alexandre-daubois alexandre-daubois changed the base branch from master to PHP-8.3 August 7, 2025 07:45
@arnaud-lb
Copy link
Member

The problem also happens when the array is converted from packed to hash:

class C {
    function __destruct() {
        global $arr;
        // array is converted from packed to hash. in_hash->arPacked becomes invalid.
        $arr["str"] = 0;
    }
}

$arr = ["1", new C, "2"];

array_splice($arr, 1, 2);

Or if the array is released entirely:

class C {
    function __destruct() {
        global $arr;
        $arr = null;
    }
}

$arr = ["1", new C, "2"];

array_splice($arr, 1, 2);

An alternative fix would be to increase the refcount of in_hash before processing it. CoW will ensure that the array can not be modified.

@alexandre-daubois
Copy link
Member Author

alexandre-daubois commented Aug 7, 2025

I tried by using GC_ADDREF, but then calling the underlying zend_hash_packed_del_val() fails because it asserts HT_ASSERT_RC1. Am I missing something?

@arnaud-lb
Copy link
Member

I missed that the function is also mutating in_hash. This gives us two possible solutions:

  • Avoid mutating in_hash in php_splice(), so that we can GC_ADDREF() in_hash. The purpose of the mutations seems to be to release the removed values themselves, but also to help with maintenance of iterator positions.
  • Or delay releasing the removed values until the end of php_splice(), so that side effects happen when it doesn't matter anymore.

The first solution would be faster, but is like more complex.

@nielsdos
Copy link
Member

nielsdos commented Aug 7, 2025

Or instead of making it complicated, use a little hack:

diff --git a/ext/standard/array.c b/ext/standard/array.c
index a2a0459ec3b..08b0f5253d6 100644
--- a/ext/standard/array.c
+++ b/ext/standard/array.c
@@ -3214,6 +3214,10 @@ static void php_splice(HashTable *in_hash, zend_long offset, zend_long length, H
 	zval		*entry;				/* Hash entry */
 	uint32_t    iter_pos = zend_hash_iterators_lower_pos(in_hash, 0);
 
+	/* Enforce separation on running user code */
+	GC_ADDREF(in_hash);
+	HT_ALLOW_COW_VIOLATION(in_hash); /* will be reset down below when setting the flags for in_hash */
+
 	/* Get number of entries in the input hash */
 	num_in = zend_hash_num_elements(in_hash);
 
@@ -3372,18 +3376,23 @@ static void php_splice(HashTable *in_hash, zend_long offset, zend_long length, H
 	HT_SET_ITERATORS_COUNT(&out_hash, HT_ITERATORS_COUNT(in_hash));
 	HT_SET_ITERATORS_COUNT(in_hash, 0);
 	in_hash->pDestructor = NULL;
-	zend_hash_destroy(in_hash);
-
-	HT_FLAGS(in_hash)          = HT_FLAGS(&out_hash);
-	in_hash->nTableSize        = out_hash.nTableSize;
-	in_hash->nTableMask        = out_hash.nTableMask;
-	in_hash->nNumUsed          = out_hash.nNumUsed;
-	in_hash->nNumOfElements    = out_hash.nNumOfElements;
-	in_hash->nNextFreeElement  = out_hash.nNextFreeElement;
-	in_hash->arData            = out_hash.arData;
-	in_hash->pDestructor       = out_hash.pDestructor;
-
-	zend_hash_internal_pointer_reset(in_hash);
+	if (UNEXPECTED(GC_DELREF(in_hash) == 0)) {
+		zend_array_destroy(in_hash);
+		zend_hash_destroy(&out_hash);
+	} else {
+		zend_hash_destroy(in_hash);
+
+		HT_FLAGS(in_hash)          = HT_FLAGS(&out_hash);
+		in_hash->nTableSize        = out_hash.nTableSize;
+		in_hash->nTableMask        = out_hash.nTableMask;
+		in_hash->nNumUsed          = out_hash.nNumUsed;
+		in_hash->nNumOfElements    = out_hash.nNumOfElements;
+		in_hash->nNextFreeElement  = out_hash.nNextFreeElement;
+		in_hash->arData            = out_hash.arData;
+		in_hash->pDestructor       = out_hash.pDestructor;
+
+		zend_hash_internal_pointer_reset(in_hash);
+	}
 }
 /* }}} */
 

@alexandre-daubois
Copy link
Member Author

Isn't it risky to use HT_ALLOW_COW_VIOLATION? It's seems defined as an empty macro when ZEND_DEBUG is false?

Also, I tried your implementation but I think there's something wrong with this test case:

--TEST--
GH-16649: array_splice UAF with destructor adding element to array
--FILE--
<?php
class C {
    function __destruct() {
        global $arr;
        $arr[] = 0;
    }
}

$arr = ["1", new C, "2"];

array_splice($arr, 1, 2);
var_dump($arr);
?>
--EXPECT--
array(3) {
  [0]=>
  string(1) "1"
  [3]=>
  int(0)
}

It fails with the following output:

array(3) {
  [0]=>
  string(1) "1"
  [2]=>
  string(1) "2" <---- this shouldn't be here
  [3]=>
  int(0)
}

If I'm right, the "2" should not appear after the splice?

@nielsdos
Copy link
Member

nielsdos commented Aug 7, 2025

Isn't it risky to use HT_ALLOW_COW_VIOLATION?

The array is being protected by having RC>1. So I think it's fine as it can't "escape".

It's seems defined as an empty macro when ZEND_DEBUG is false?

Yes, because RC1 assertions for arrays are only checked in debug mode.

@nielsdos
Copy link
Member

nielsdos commented Aug 7, 2025

Good observation about the test. It happens because the reassignment to in_hash is skipped. There's a couple of different approach we could take, one could be to simply throw an exception when the original array has disappeared; this may be the simplest solution as this can only happen with malicious code.

@alexandre-daubois
Copy link
Member Author

Given the convoluted code required to reproduce this case, it seems to me to be a viable solution.

@alexandre-daubois alexandre-daubois force-pushed the uaf-splice branch 3 times, most recently from 050c3cf to a6bff41 Compare August 8, 2025 08:54
@alexandre-daubois
Copy link
Member Author

I implemented your solution @nielsdos. I wonder if the message should avoid mentioning destructors and be less specific in case it happens at an unexpected place yet to be detected?

Copy link
Member

@iluuu1994 iluuu1994 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks reasonable. Thanks!


if (UNEXPECTED(GC_DELREF(in_hash) == 0)) {
/* Array was completely deallocated during the operation */
zend_array_destroy(in_hash);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default branch uses zend_hash_destroy(in_hash);. zend_array_destroy() seems slightly more optimized (or at least optimized in different ways), but we should stay consistent. Adjusting the default branch would also be possible, but should be measured and done on master.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it!

Copy link
Member

@nielsdos nielsdos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this approach

@iluuu1994
Copy link
Member

Oh, I now also see why the code uses zend_hash_destroy() rather than zend_array_destroy(), it's to keep the HashTable allocation alive. I'm afraid you'll have to revert my suggestion. Sorry!

@alexandre-daubois
Copy link
Member Author

alexandre-daubois commented Aug 11, 2025

Yes I just spotted that with the CI. No worries 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants